package com.mimosa.util.config.ini;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.StringTokenizer;

/**
 * Loads, edits and saves a section of an INI-style configuration file. This
 * class does actually belong to the internals of {@link IniEditor} and should
 * rarely ever be used directly. It's exposed because it can be useful for
 * plain, section-less configuration files (Java-style properties, for example).
 */
public class Section {

	private String name;
	private Map<String, Option> options;
	private List<Line> lines;
	private char[] optionDelims;
	private char[] optionDelimsSorted;
	private char[] commentDelims;
	private char[] commentDelimsSorted;
	private boolean isCaseSensitive;
	private OptionFormat optionFormat;

	private static final char[] DEFAULT_OPTION_DELIMS = new char[] { '=', ':' };
	private static final char[] DEFAULT_COMMENT_DELIMS = new char[] { '#', ';' };
	private static final char[] OPTION_DELIMS_WHITESPACE = new char[] { ' ', '\t' };
	private static final boolean DEFAULT_CASE_SENSITIVITY = false;
	public static final String DEFAULT_OPTION_FORMAT = "%s %s %s";

	public static final char HEADER_START = '[';
	public static final char HEADER_END = ']';
	private static final int NAME_MAXLENGTH = 1024;
	private static final char[] INVALID_NAME_CHARS = { HEADER_START, HEADER_END };

	/**
	 * Constructs a new section.
	 * 
	 * @param name
	 *            the section's name
	 * @throws IllegalArgumentException
	 *             the section's name is illegal
	 */
	public Section(String name) {
		this(name, null);
	}

	/**
	 * Constructs a new section, specifying case-sensitivity.
	 * 
	 * @param name
	 *            the section's name
	 * @param isCaseSensitive
	 *            option names are case-sensitive if this is true
	 * @throws IllegalArgumentException
	 *             the section's name is illegal
	 */
	public Section(String name, boolean isCaseSensitive) {
		this(name, null, isCaseSensitive);
	}

	/**
	 * Constructs a new section, defining comment delimiters.
	 * 
	 * @param name
	 *            the section's name
	 * @param delims
	 *            an array of characters to be recognized as starters of comment
	 *            lines; the first of them will be used for newly created
	 *            comments
	 * @throws IllegalArgumentException
	 *             the section's name is illegal
	 */
	public Section(String name, char[] delims) {
		this(name, delims, DEFAULT_CASE_SENSITIVITY);
	}

	/**
	 * Constructs a new section, defining comment delimiters.
	 * 
	 * @param name
	 *            the section's name
	 * @param delims
	 *            an array of characters to be recognized as starters of comment
	 *            lines; the first of them will be used for newly created
	 *            comments
	 * @param isCaseSensitive
	 *            option names are case-sensitive if this is true
	 * @throws IllegalArgumentException
	 *             the section's name is illegal
	 */
	public Section(String name, char[] delims, boolean isCaseSensitive) {
		if (!validName(name)) {
			throw new IllegalArgumentException("Illegal section name:" + name);
		}
		this.name = name;
		this.isCaseSensitive = isCaseSensitive;
		this.options = new HashMap<String, Option>();
		this.lines = new LinkedList<Line>();
		this.optionDelims = DEFAULT_OPTION_DELIMS;
		this.commentDelims = (delims == null ? DEFAULT_COMMENT_DELIMS : delims);
		this.optionFormat = new OptionFormat(DEFAULT_OPTION_FORMAT);
		// sorting so we can later use binary search
		this.optionDelimsSorted = new char[this.optionDelims.length];
		System.arraycopy(this.optionDelims, 0, this.optionDelimsSorted, 0, this.optionDelims.length);
		this.commentDelimsSorted = new char[this.commentDelims.length];
		System.arraycopy(this.commentDelims, 0, this.commentDelimsSorted, 0, this.commentDelims.length);
		Arrays.sort(this.optionDelimsSorted);
		Arrays.sort(this.commentDelimsSorted);
	}

	/**
	 * Sets the option format for this section to the given string. Options in
	 * this section will be rendered according to the given format string. The
	 * string must contain <code>%s</code> three times, these will be replaced
	 * with the option name, the option separator and the option value in this
	 * order. Literal percentage signs must be escaped by preceding them with
	 * another percentage sign (i.e., <code>%%</code> corresponds to one
	 * percentage sign). The default format string is <code>"%s %s %s"</code>.
	 * 
	 * Option formats may look like format strings as supported by Java 1.5, but
	 * the string is in fact parsed in a custom fashion to guarantee backwards
	 * compatibility. So don't try clever stuff like using format conversion
	 * types other than <code>%s</code>.
	 * 
	 * @param formatString
	 *            a format string, containing <code>%s</code> exactly three
	 *            times
	 * @throws IllegalArgumentException
	 *             if the format string is illegal
	 */
	public void setOptionFormatString(String formatString) {
		this.setOptionFormat(new OptionFormat(formatString));
	}

	/**
	 * Sets the option format for this section. Options will be rendered
	 * according to the given format when printed.
	 * 
	 * @param format
	 *            a compiled option format
	 */
	public void setOptionFormat(OptionFormat format) {
		this.optionFormat = format;
	}

	/**
	 * Returns the names of all options in this section.
	 * 
	 * @return list of names of this section's options in original/insertion
	 *         order
	 */
	public List<String> optionNames() {
		List<String> optNames = new LinkedList<String>();
		Iterator<Line> it = this.lines.iterator();
		while (it.hasNext()) {
			Object line = it.next();
			if (line instanceof Option) {
				optNames.add(((Option) line).name());
			}
		}
		return optNames;
	}

	/**
	 * Checks whether a given option exists in this section.
	 * 
	 * @param name
	 *            the name of the option to test for
	 * @return true if the option exists in this section
	 */
	public boolean hasOption(String name) {
		return this.options.containsKey(normOption(name));
	}

	/**
	 * Returns an option's value.
	 * 
	 * @param option
	 *            the name of the option
	 * @return the requested option's value or <code>null</code> if no option
	 *         with the specified name exists
	 */
	public String get(String option) {
		String normed = normOption(option);
		if (hasOption(normed)) {
			return getOption(normed).value();
		}
		return null;
	}

	/**
	 * Sets an option's value and creates the option if it doesn't exist.
	 * 
	 * @param option
	 *            the option's name
	 * @param value
	 *            the option's value
	 * @throws IllegalArgumentException
	 *             the option name is illegal, ie contains a '=' character or
	 *             consists only of white space
	 */
	public void set(String option, String value) {
		set(option, value, this.optionDelims[0]);
	}

	/**
	 * Sets an option's value and creates the option if it doesn't exist.
	 * 
	 * @param option
	 *            the option's name
	 * @param value
	 *            the option's value
	 * @param delim
	 *            the delimiter between name and value for this option
	 * @throws IllegalArgumentException
	 *             the option name is illegal, ie contains a '=' character or
	 *             consists only of white space
	 */
	public void set(String option, String value, char delim) {
		String normed = normOption(option);
		if (hasOption(normed)) {
			getOption(normed).set(value);
		} else {
			// Option constructor might throw IllegalArgumentException
			Option opt = new Option(normed, value, delim, this.optionFormat);
			this.options.put(normed, opt);
			this.lines.add(opt);
		}
	}

	/**
	 * Removes an option if it exists.
	 * 
	 * @param option
	 *            the name of the option
	 * @return <code>true</code> if the option was actually removed
	 */
	public boolean remove(String option) {
		String normed = normOption(option);
		if (hasOption(normed)) {
			this.lines.remove(getOption(normed));
			this.options.remove(normed);
			return true;
		} else {
			return false;
		}
	}

	/**
	 * Adds a comment line to the end of this section. A comment spanning
	 * several lines (ie with line breaks) will be split up, one comment line
	 * for each line.
	 * 
	 * @param comment
	 *            the comment
	 */
	public void addComment(String comment) {
		addComment(comment, this.commentDelims[0]);
	}

	/**
	 * Adds a comment line to the end of this section. A comment spanning
	 * several lines (ie with line breaks) will be split up, one comment line
	 * for each line.
	 * 
	 * @param comment
	 *            the comment
	 * @param delim
	 *            the delimiter used to mark the start of this comment
	 */
	public void addComment(String comment, char delim) {
		StringTokenizer st = new StringTokenizer(comment.trim(), NEWLINE_CHARS);
		while (st.hasMoreTokens()) {
			this.lines.add(new Comment(st.nextToken(), delim));
		}
	}

	private static final String NEWLINE_CHARS = "\n\r";
	private static final Line BLANK_LINE = new Line() {
		public String toString() {
			return "";
		}
	};

	/**
	 * Adds a blank line to the end of this section.
	 */
	public void addBlankLine() {
		this.lines.add(BLANK_LINE);
	}

	/**
	 * Loads options from a reader into this instance. Will read from the stream
	 * until it hits a section header, ie a '[' character, and resets the reader
	 * to point to this character.
	 * 
	 * @param reader
	 *            where to read from
	 * @throws IOException
	 *             at an I/O problem
	 */
	public void load(BufferedReader reader) throws IOException {
		while (reader.ready()) {
			reader.mark(NAME_MAXLENGTH);
			String line = reader.readLine().trim();

			// Check for section header
			if (line.length() > 0 && line.charAt(0) == HEADER_START) {
				reader.reset();
				return;
			}

			int delimIndex = -1;
			// blank line
			if (line.equals("")) {
				this.addBlankLine();
			}
			// comment line
			else if ((delimIndex = Arrays.binarySearch(this.commentDelimsSorted, line.charAt(0))) >= 0) {
				addComment(line.substring(1), this.commentDelimsSorted[delimIndex]);
			}
			// option line
			else {
				delimIndex = -1;
				int delimNum = -1;
				int lastSpaceIndex = -1;
				for (int i = 0, l = line.length(); i < l && delimIndex < 0; i++) {
					delimNum = Arrays.binarySearch(this.optionDelimsSorted, line.charAt(i));
					if (delimNum >= 0) {
						delimIndex = i;
					} else {
						boolean isSpace = Arrays.binarySearch(Section.OPTION_DELIMS_WHITESPACE, line.charAt(i)) >= 0;
						if (!isSpace && lastSpaceIndex >= 0) {
							break;
						} else if (isSpace) {
							lastSpaceIndex = i;
						}
					}
				}
				// delimiter at start of line
				if (delimIndex == 0) {
					// XXX what's a man got to do?
				}
				// no delimiter found
				else if (delimIndex < 0) {
					if (lastSpaceIndex < 0) {
						this.set(line, "");
					} else {
						this.set(line.substring(0, lastSpaceIndex), line.substring(lastSpaceIndex + 1));
					}
				}
				// delimiter found
				else {
					this.set(line.substring(0, delimIndex), line.substring(delimIndex + 1), line.charAt(delimIndex));
				}
			}
		}
	}

	/**
	 * Prints this section to a print writer.
	 * 
	 * @param writer
	 *            where to write
	 * @throws IOException
	 *             at an I/O problem
	 */
	public void save(PrintWriter writer) throws IOException {
		Iterator<Line> it = this.lines.iterator();
		while (it.hasNext()) {
			writer.println(((Line) it.next()).toString());
		}
		if (writer.checkError()) {
			throw new IOException();
		}
	}

	/**
	 * Returns an actual Option instance.
	 * 
	 * @param option
	 *            the name of the option, assumed to be normed already (!)
	 * @return the requested Option instance
	 * @throws NullPointerException
	 *             if no option with the specified name exists
	 */
	private Option getOption(String name) {
		return (Option) this.options.get(name);
	}

	/**
	 * Returns the bracketed header of this section as appearing in an actual
	 * INI file.
	 * 
	 * @return the section's name in brackets
	 */
	String header() {
		return HEADER_START + this.name + HEADER_END;
	}

	/**
	 * Checks a string for validity as a section name. It can't contain the
	 * characters '[' and ']'. An empty string or one consisting only of white
	 * space isn't allowed either.
	 * 
	 * @param name
	 *            the name to validate
	 * @return true if the name validates as a section name
	 */
	private static boolean validName(String name) {
		if (name.trim().equals("")) {
			return false;
		}
		for (int i = 0; i < INVALID_NAME_CHARS.length; i++) {
			if (name.indexOf(INVALID_NAME_CHARS[i]) >= 0) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Normalizes an arbitrary string for use as an option name, ie makes it
	 * lower-case (provided this section isn't case-sensitive) and trims leading
	 * and trailing white space.
	 * 
	 * @param name
	 *            the string to be used as option name
	 * @return a normalized option name
	 */
	private String normOption(String name) {
		if (!this.isCaseSensitive) {
			name = name.toLowerCase();
		}
		return name.trim();
	}

}
